在非常早期的 Web 技术中,大家还是使用 JSP 这种古老的模板语法来编写前端的页面,然后直接将 JSP 文件放到服务端,在服务端填入数据并渲染出完整的页面内容,可以说那个时代的做法是天然的服务端渲染。但随着 AJAX 技术的成熟以及各种前端框架(如 Vue、React)的兴起,前后端分离的开发模式逐渐成为常态,前端只负责页面 UI 及逻辑的开发,而服务端只负责提供数据接口,这种开发方式下的页面渲染也叫客户端渲染(Client Side Render,简称 CSR)。
但客户端渲染也存在着一定的问题,例如首屏加载比较慢、对 SEO 不太友好,因此 SSR (Server Side Render)即服务端渲染技术应运而生,它在保留 CSR 技术栈的同时,也能解决 CSR 的各种问题。
这一节,我们一起来聊聊SSR,说说它的基本原理,以及如何借助 Vite 的各项构建能力实现 SSR,让你体会到 SSR 的各个生命周期是如何串联起来的。
需要说明的是,本节最终的目的并不是让大家学习某个现有的 SSR 解决方案(如 Nuxt),或者简单了解一下实现原理,而是教大家如何从底层开始手动搭建一个可以深度定制的 SSR 项目,并且解决各种 SSR 中的工程化问题。
# SSR 基本概念
首先我们来分析一下 CSR 的问题,它的 HTML 产物一般是如下的结构:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
<link rel="stylesheet" href="xxx.css" />
</head>
<body>
<!-- 一开始没有页面内容 -->
<div id="root"></div>
<!-- 通过 JS 执行来渲染页面 -->
<script src="xxx.chunk.js"></script>
</body>
</html>
顺便我们也回顾一下浏览器的渲染流程,如下图所示:

当浏览器拿到如上的 HTML 内容之后,其实并不能渲染完整的页面内容,因为此时的 body 中基本只有一个空的 div 节点,并没有填入真正的页面内容。而接下来浏览器开始下载并执行 JS 代码,经历了框架初始化、数据请求、DOM 插入等操作之后才能渲染出完整的页面。也就是说,在 CSR 中完整的页面内容本质上通过 JS 代码执行之后才能够渲染。这主要会导致两个方面的问题:
- 首屏加载速度比较慢。首屏加载需要依赖 JS 的执行,下载和执行 JS 都可能是非常耗时的操作,尤其是在一些网络不佳的场景,或者性能敏感的低端机下。
- 对 SEO(搜索引擎优化) 不友好。页面 HTML 没有具体的页面内容,导致搜索引擎爬虫无法获取关键词信息,导致网站排名受到影响。
那么 SSR 是如何解决这些问题的呢?
在 SSR 的场景下,服务端生成好完整的 HTML 内容,直接返回给浏览器,浏览器能够根据 HTML 渲染出完整的首屏内容,而不需要依赖 JS 的加载,这样一方面能够降低首屏渲染的时间,另一方面也能将完整的页面内容展现给搜索引擎的爬虫,利于 SEO。
当然,SSR 中只能生成页面的内容和结构,并不能完成事件绑定,因此需要在浏览器中执行 CSR 的 JS 脚本,完成事件绑定,让页面拥有交互的能力,这个过程被称作hydrate(翻译为注水或者激活)。同时,像这样服务端渲染 + 客户端 hydrate 的应用也被称为同构应用。
# SSR 生命周期分析
前面我们提到了 SSR 会在服务端(这里主要指 Node.js 端)提前渲染出完整的 HTML 内容,那这是如何做到的呢?
首先需要保证前端的代码经过编译后放到服务端中能够正常执行,其次在服务端渲染前端组件,生成并组装应用的 HTML。这就涉及到 SSR 应用的两大生命周期: 构建时和运行时,我们不妨来仔细梳理一下。
我们先来看看构建时需要做哪些事情:
- 解决模块加载问题。在原有的构建过程之外,需要加入
SSR 构建的过程 ,具体来说,我们需要另外生成一份CommonJS格式的产物,使之能在 Node.js 正常加载。当然,随着 Node.js 本身对 ESM 的支持越来越成熟,我们也可以复用前端 ESM 格式的代码,Vite 在开发阶段进行 SSR 构建也是这样的思路。

- 移除样式代码的引入。直接引入一行 css 在服务端其实是无法执行的,因为 Node.js 并不能解析 CSS 的内容。但
CSS Modules的情况除外,如下所示:
import styles from './index.module.css